fix(workbook): make the zoned and sparkline wire forms canonical-only - #774
Merged
Conversation
…768) `schema/workbook.v1.schema.json` had no branch for `zoned` and none for `sparkline`, so a workbook holding either serialized correctly from Rust and then failed validation against our own published contract -- a consumer doing the right thing was told the file was malformed. Both variants are reachable inside a spill anchor's array as well (`={TZNOW("UTC"),TZNOW("Europe/Berlin")}` and `={SPARKLINE({1,2,3}),...}` both recalc to an `array` of them), so `scalarValue` was missing them too. Both lists gain both branches; the change is purely additive, so validation of every existing variant is byte-for-byte unchanged. The branches are derived from real serializer output rather than from the enum: `zoned` carries the canonical RFC-9557 string, and `sparkline` carries the whole parsed spec as `{charttype, data, options}` with `options` a list of `[key, value]` pairs whose values are drawn from a narrower set than `scalarValue` -- only number, text, boolean and empty can be a data point or an option value. The durable part is the test, not the two branches. Every `Value` variant now has a representative that is serialized through `Workbook::to_json` and validated against the committed schema, and a new variant cannot slip past it: the variant-name `match` is exhaustive, and the sample set is checked against the variant list read out of `src/value.rs` itself, so a variant with no sample -- or a sample with no schema branch -- fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…#768) Review follow-up. The schema branches were sized to the reader for `zoned` but not for `charttype`, and the reader itself was more lenient than the wire was ever meant to be. Four of the five findings are the same defect: the wire reader reuses parsers written for the *formula* level, where leniency is required, and inherits leniency the writer never exercises. Reader tightened, not schema widened, in two places -- safe now because the serializer only ever emits the canonical spelling and SPARKLINE is a day old, so nothing in the wild carries either form: - `charttype` is canonical lower-case on the wire. `SparklineChartType::parse` is ASCII case-insensitive so that `=SPARKLINE({1,2},{"charttype","LINE"})` evaluates, but `{"charttype":"Line"}` on the wire loaded fine and then failed schema validation. `parse_sparkline` now requires the canonical spelling, as it already did for option keys eight lines below. - A `zoned` string may not be padded. `parse_rfc9557` trims, and trims the bracketed zone, for the same formula-level reason; `" ...Z"` and `"...[ Europe/Berlin ]"` therefore loaded and failed validation. The guard is at the wire boundary, so nothing relying on the formula-level trim changes. The writer gains the guard it was missing: `SparklineSpecWire` now rejects a spec with fewer than two data points, an upper-case option key, or a `charttype` option, mirroring what the `Array` arm of the same `Serialize` impl already does for its own shape rules. `SparklineSpec` is a public struct, so these were constructible, and encoding one produced bytes that neither the decoder nor the schema accepts -- a round-trip guarantee the crate makes and was breaking. The `zoned` pattern is widened where the reader is wider (a space separator, as RFC 3339 permits) and bounded where it can be: month, day, hour, minute, second including a leap second, and the offset's +/-23:59 range. Calendar validity (`2026-04-31`), the representable instant range (`9999-01-01`) and tzdb membership remain inexpressible, and the branch description now says exactly that instead of the two vaguer claims it made before. Also: the root description tells consumers to validate with draft 2020-12, because a draft-07 validator silently ignores `prefixItems` and drops all inner validation of sparkline option pairs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
Test Coverage by Category
✓ = 100% passing · ⚠ = known deviation · The ~79,424 total counts formula evaluations (each conformance row and each property case = 1). GitHub Checks reports 3,741 Rust test functions: 2,897 unit + 159 property functions (shown as cases above) + 685 conformance/integration. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
closes #768
Summary
The published JSON schema had no branch for
zonedand none forsparkline, so a workbook containing either serialized correctly and then failed validation against our own schema. A consumer doing the right thing was told the file was malformed.Fixing it turned out to be less about the schema than about the wire reader.
The root cause is one thing, not five
The wire reader reuses parsers written for the formula level, where leniency is required, and inherits leniency the writer never exercises.
=SPARKLINE({1,2},{"charttype","LINE"})must work in Sheets, soSparklineChartType::parseis case-insensitive. Butfrom_jsonreusing it meant the wire accepted{"charttype":"Line"}— bytes our serializer never emits. The evidence that this was unintended sits eight lines away in the same function: the reader already rejects a non-lower-case option key.Same story for
zoned: core'sparse_rfc9557trims, becauseTZPARSEoperates on user-typed arguments. The wire inherited that and accepted"…Z\n".So the reader was tightened, not the schema widened, in both places. Widening instead would have documented junk as a valid document shape, which is the opposite of what a published contract is for. Core's formula-level parsers are untouched — only
from_jsongot stricter.from_jsonacceptsDocuments with a non-canonical
charttypecasing, or a whitespace-paddedzonedvalue, now fail to load. Both are unreachable from our serializer, and SPARKLINE shipped a day ago, so nothing in the wild carries either. It is safe now and gets harder every day — worth flagging as a deliberate compatibility decision rather than a silent one.Three gaps beyond the two the issue named
={TZNOW("UTC"),TZNOW("Europe/Berlin")}recalcs to an array of them, andscalarValuewas missing both — so fixing only the top-level branches would have left whole documents failing.to_jsonsucceeded on a 0- or 1-point sparkline; both the schema andfrom_jsonreject those, breaking the crate's own round-trip guarantee. Now guarded at serialization time viaS::Error::custom, mirroring whatValue::Arrayalready does eight lines up. Extended to upper-case option keys and acharttypeoption too — all three were constructible on a public struct and all three produced unreadable bytes.zonedpattern didn't bound field ranges."2026-99-99T99:99:99+99:99"validated. Now bounded, including leap second:60(which the reader accepts and normalizes) and the offset's true±23:59.The branches were derived from bytes, not from the enum
Values were serialized and the output read back. That caught a detail an eyeballed schema gets wrong: the bracketed zone is present for an IANA zone (
…+01:00[Europe/Berlin]) and absent for a fixed offset.The durable part is the test, not the branches
crates/workbook/tests/schema_value_variant_tests.rsvalidates serializer output against the schema for everyValuevariant, with two independent coverage gates — an exhaustivematch(compile-time) and a runtime check parsing the variant list out ofsrc/value.rs. Two hand-added branches without it would just reset the clock.It was mutated, not asserted. Adding a hypothetical variant fails at each of three stages: compile error when the match isn't exhaustive, coverage failure when the sample is missing, validation failure when the schema branch is missing. Deleting the
zonedbranch fails 5 of 7 tests by name.Every new guard was likewise reverted and confirmed to fail:
should have been rejected: {"charttype":"Line"…}the deserializer should have rejected {" 2026-01-01T12:00:00Z"}should have been rejected: SparklineSpec { data: [], … }[Tt ]→[Tt]the schema should have accepted 2026-01-01 12:00:00Zthe schema should have rejected 2026-99-99T99:99:99+99:99Still inexpressible, and now documented as such
Calendar validity (
2026-04-31matches the pattern; the reader rejects it), the representable instant range (9999-01-01matches; it's an i64 nanosecond count), and tzdb membership. The description previously named an omission among these as if it were a limitation — corrected.Also added: consumers must use a draft 2020-12 validator. Draft-07 silently no-ops
prefixItemsand drops all option-pair inner validation.How to test
To see the original bug: check out
main, construct a workbook containing aTZNOWresult, serialize it, and validate againstcrates/workbook/schema/workbook.v1.schema.json— it fails. On this branch it passes.Review
charttype— fix(workbook): the JSON schema is missing branches for non-scalar value variants #768 with the sign flipped, in the very branch added to fix fix(workbook): the JSON schema is missing branches for non-scalar value variants #768. The reviewer also verified purely-additive behaviourally (9 pre-existing and 6 previously-rejected shapes through both schema revisions, zero drift), reproduced all three test mutations, and probed 24 sparkline shapes finding reader/schema agreement on every one but the blocker.Test plan
cargo test --workspace— zero failurescargo clippy --workspace -- -D warnings— exit 0cargo nextest run --workspace --profile ci— exit 0Related
🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.